feat(tables): add select & multi-select column types#5873
feat(tables): add select & multi-select column types#5873TheodoreSpeaks wants to merge 20 commits into
Conversation
Adds two enum-style column types where the column declares a fixed set of options (stable id + name + palette color) and every cell is constrained to them. - COLUMN_TYPES gains `select` / `multiselect`; SELECT_COLORS is a fixed, theme-aware palette mapping 1:1 to Badge color variants (no raw hex) - ColumnDefinition.options carries the option set. Cells store option *ids* (a string for select, string[] for multiselect) so renaming or recoloring an option never rewrites row data - Row validation enforces membership; coercion tolerantly maps an option *name* to its id for tool/import writes and drops unmatched entries - validateColumnDefinition enforces non-empty, unique-id, unique-name and valid-color option sets, and rejects options on non-select columns - Contract gains selectOptionSchema plus a cross-field refine requiring options exactly on select types; routes thread options through type changes and a new options-only updateColumnOptions path No migration: column config already lives in the user_table_definitions schema JSONB blob. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd
Surfaces the new enum column types in the tables grid. - New `select-field/` module: SelectPill (colored option via the shared Badge palette), SelectValueEditor (one ChipDropdown-backed picker reused by every edit surface), SelectOptionsEditor (add/rename/recolor/remove) - Option colors are picked from inline squircle swatches — no labels, no nested dropdown. Each swatch's fill is a Badge in that variant, so the palette stays single-sourced and theme-aware - Cells render option pills; an empty select cell shows a muted "None" so it reads as a dropdown. The single-select menu always offers "None" to clear - Wired into all three edit surfaces (inline cell, expanded popover, row modal) plus the type picker and column-type icons - ChipDropdown gains `defaultOpen`/`onOpenChange` so the inline cell editor can open on mount and commit when the menu closes; open state is now controlled in both single and multi modes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd
Column auto-resize measured `String(val)` for every non-json/date column, which for a select cell is the opaque option id (and for multiselect the comma-joined id array). Selecting auto-fit on a select column therefore sized it to ids the user never sees. Measure the resolved option names instead, matching what the pills actually render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd
Removes the per-option swatch picker and defaults every option to the neutral gray pill. The `color` field stays in the data model and the SELECT_COLORS palette/contract are untouched, so a picker can be re-added later as a pure UI change with no migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview API & schema: Column create/update routes (app + v1) route real type changes through Server logic: UI: Column sidebar adds Select type, option editor, and multiselect toggle (no unique constraint on select). Grid shows colored pills, inline dropdown editing, row-modal picker; column auto-fit uses option labels. React Query invalidates schema-only on metadata column changes to avoid refetching all rows. Export & DX: CSV/JSON exports resolve ids to display names; client contract validation errors summarize Zod paths. Grid treats structurally equal cell values (e.g. unchanged multiselect) as no-ops for saves/undo. Reviewed by Cursor Bugbot for commit 228ba69. Bugbot is set up for automated code reviews on this repo. Configure here. |
Greptile SummaryThis PR adds select and multiselect columns to Tables. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (5): Last reviewed commit: "improvement(tables): inline select edit ..." | Re-trigger Greptile |
Addresses review findings on the select/multiselect column types: - Column type conversion now checks each existing value against the target option set (resolve by id or name); a `select`/`multiselect` change is blocked when values don't fit, instead of accepting shapes that later coercion would strand or silently drop - Inline select editor discards its draft on Escape (matching the text/date editors) rather than committing on menu close - A required single-select no longer offers "None" — clearing to null could never be committed Exports resolveSelectOptionId from validation for the conversion gate.
|
@cursor review |
Round 2 review fixes: - A required multiselect can no longer be emptied — the toggle that would remove the last option is ignored, since an empty selection can't be committed (server rejects it). Mirrors the required single-select "None" guard. - Inline cell save now compares old vs new value structurally, so a no-op edit (e.g. opening a multiselect and closing it unchanged, producing a new array reference) no longer writes a row update or pushes an undo entry. Uses JSON compare for arrays/objects, matching the existing optionsEqual convention; primitives keep the === fast path.
|
@cursor review |
Round 3 review fixes: - Expanded-cell popover normalizes a multiselect's initial value with toSelectedIds, so a single option-id string (the normal shape right after a select->multiselect conversion, before the row is rewritten) is no longer collapsed to an empty array and cleared on save. - Column PATCH now only routes to updateColumnType on a real type change. updateColumnType early-returns on an unchanged type, so a payload repeating the current type together with new options previously applied neither; an unchanged type with options now routes to the options-only update. Fixed in both the internal and v1 column routes.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 4db5e88. Configure here.
- Double-clicking a select/multiselect cell now opens the inline option dropdown (like date/number) instead of the fixed-height text popover, which rendered just a small dropdown floating in a large empty container. Removes the now-unreachable ExpandedSelectEditor from the expanded-cell popover. - Options editor's add/remove controls match the sibling Filter UI: a ghost, muted "Add option" button with a small plus, and an X remove icon.
|
@cursor review |
- Collapse `select` + `multiselect` into a single `select` column type with a `multiple` boolean. A cell holds one option id when single, an array when multiple. Validation/coercion, the contract, routes, and all cell/editor code now branch on `column.multiple` instead of a separate type. - The column-config sidebar exposes an "Allow multiple" toggle (create and edit) and no longer offers "Unique" on select columns. - Give the closed select cell a dropdown affordance: it renders emcn's `comboboxVariants` chrome (border + chevron, compacted to the row height), with the pills inside and the chevron flipping while the menu is open. - Single↔multiple toggles reconcile lazily on the next row write (single tolerates an array by taking its first resolved option).
Drop the combobox chrome (border + chevron) from the select cell — the plain option pills read better. Reverts the comboboxVariants barrel export too, since nothing else uses it.
| fullWidth={fullWidth} | ||
| matchTriggerWidth={false} | ||
| /> | ||
| ) |
There was a problem hiding this comment.
Empty multiselect shows All
Medium Severity
SelectValueEditor sets showAllOption={false} and placeholder='Select options' for multiselect, but ChipDropdown in multiple mode ignores placeholder and renders allLabel (defaulting to All) when the selection is empty. Empty optional multiselect cells in the row modal therefore read as if every option were selected.
Reviewed by Cursor Bugbot for commit d55f94b. Configure here.
…ption
Switching a select column from multiple to single would silently keep only the
first option of any multi-valued cell. updateColumnOptions now scans the rows on
that transition and errors ("N row(s) have multiple options selected") instead
of dropping data, mirroring the type-change compatibility guard.
| // Single or multi, every part of the value must resolve to an option. | ||
| const parts = Array.isArray(value) ? value : [value] | ||
| return parts.every((v) => resolveSelectOptionId(v as JsonValue, targetOptions) !== null) | ||
| } |
There was a problem hiding this comment.
Single-select conversion allows multi-values
Medium Severity
isValueCompatibleWithType for select only checks that every array element resolves to an option. It never consults whether the target is single-select, so a multi-element array is treated as compatible. updateColumnOptions already blocks multi→single for this reason and even comments that it mirrors a type-change guard, but that guard is missing here—so conversion can leave arrays that the next coerce silently reduces to the first id.
Reviewed by Cursor Bugbot for commit 60b15cc. Configure here.
| switch (targetType) { | ||
| case 'string': | ||
| return true | ||
| case 'select': { |
There was a problem hiding this comment.
Multiselect-to-text drops array data
Medium Severity
Compatibility for target type string returns true for any value, including multiselect string[] cells. Changing a multiselect column to text therefore succeeds while leaving arrays in row data; the next coerce cannot turn those arrays into strings and nulls optional cells (or fails required ones), so selections are lost without the conversion error used for number/date/boolean.
Reviewed by Cursor Bugbot for commit 60b15cc. Configure here.
| return JSON.stringify(a) === JSON.stringify(b) | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
Empty multiselect false change
Medium Severity
Untouched multiselect cells are stored as null, but the inline editor always commits [] for an empty selection. cellValuesEqual does not treat null and [] as equal, so opening and dismissing an empty multiselect still counts as a change, writes a row update, and pushes an undo entry.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit d06c399. Configure here.
| ...(typeChanged ? { type: typeInput } : {}), | ||
| ...(uniqueChanged ? { unique: uniqueInput } : {}), | ||
| ...(wantsOptions && (typeChanged || optionsChanged) ? { options: trimmedOptions } : {}), | ||
| ...(wantsOptions && (typeChanged || multipleChanged) ? { multiple: multipleInput } : {}), |
There was a problem hiding this comment.
Unique stuck on select convert
Low Severity
Converting a unique column to select hides the Unique control and suppresses uniqueChanged, and the save payload never clears unique. A previously unique column therefore keeps its unique constraint after becoming select, with no way to turn it off while the type remains select.
Reviewed by Cursor Bugbot for commit d06c399. Configure here.
…pills
When an option is deleted, cells that referenced it previously rendered a gray
fallback pill labeled with the raw internal id. They now drop the orphaned id
and fall back to empty ("None"). Editors seed their selection from the still-
valid ids too, so editing/saving such a cell writes the cleaned value.
| onChange={(ids) => { | ||
| if (column.required && ids.length === 0) return | ||
| onChange(ids) | ||
| }} |
There was a problem hiding this comment.
Empty multiselect breaks isEmpty filters
Medium Severity
Cleared multiselect cells are stored as [] (editors and coerce), which the UI shows as None, but $empty only treats JSON null/absent/empty string as empty. Filtered “is empty” views therefore miss cleared multiselect cells, and “is not empty” includes them.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 3e0b68a. Configure here.
Match the sub-block list editors (filter/sort builders): a full-width, dashed-border ghost "Add option" button instead of a plain text button.
…opy log
- Add/update column are metadata-only server-side (row data never changes), so
they now invalidate just the schema, not the rows. Saving a column (e.g.
editing select options) no longer triggers a full rows refetch/flash; cells
re-render from the refetched schema. Delete-column and workflow-group ops keep
the rows invalidation since they do change row data.
- "Failed to copy rows" logged an empty {} for DOMExceptions; log the extracted
message so the real reason (e.g. lost transient activation) is visible.
- Tables created before the select/multiselect merge stored `type: 'multiselect'`, which the removed type no longer accepts — every response carrying such a column failed contract validation and column edits threw. getTableById now maps `multiselect` → `select` + `multiple: true` on read (covers reads, column ops via withLockedTable, and their responses); the migrated shape persists on the table's next schema write. - requestJson's "Response failed contract validation" now appends a short "field: reason" summary of the Zod issues instead of a bare message, so the failing field is visible.
Only local dev tables ever held the removed `multiselect` type (the feature never shipped), so the read-time backward-compat mapping isn't warranted. Keeps the readable contract-validation error from the same change.
Replace the "Add option" button with a trailing empty row: the first keystroke materializes the option and focus jumps into it (cursor at end) so typing flows straight through. Enter in an option jumps back to the trailing row to add the next one. Removing a row is unchanged.
| const id = resolveSelectOptionId(entry, options) | ||
| if (id !== null && !ids.includes(id)) ids.push(id) | ||
| } | ||
| return { ok: true, value: ids } |
There was a problem hiding this comment.
Multiselect clipboard round-trip clears cells
High Severity
Multiselect cells copy as a JSON array string, but paste/coercion never parses that string back into ids. coerceValueToColumnType wraps the whole blob as one entry, resolves nothing, and stores [], so copy-paste (and CSV re-import) silently clears multiselect values.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 0c465c8. Configure here.
CSV/JSON export serialized the raw stored value for select cells — an option id (or an array of ids for multi) — so downloads showed opaque `opt_…` ids. Export now resolves ids to option names: CSV comma-joins multi names; JSON resolves values before the id→name key translation. Ids with no matching option (deleted) are dropped.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 9 total unresolved issues (including 8 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 228ba69. Configure here.
| return neutralizeCsvFormula(text) | ||
| } | ||
| return formatCsvValue(value) | ||
| } |
There was a problem hiding this comment.
Sync export leaves select IDs raw
High Severity
formatCsvCell / resolveSelectExportValue were added so exports show option names, and the async export runner uses them, but the synchronous /export route still formats cells with raw stored values. Tables under the async threshold therefore export opaque option ids (and unresolved JSON select values) instead of labels, so the two export paths no longer match.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 228ba69. Configure here.


Summary
select(single) andmultiselectenum column types to Tables — a column declares a fixed set of options and every cell is constrained to them, rendered as pillscolorfield + full palette stay in the model/contract so a picker can be re-added later with no migrationChipDropdowngainsdefaultOpen/onOpenChangefor inline editingKnown limitations
options(follow-up)Type of Change
Testing
Tested manually.
bun run lintclean,check:api-validation:strictpassed,check:react-querypassed; 335 table + 14 emcn unit tests green (incl. newvalidation.test.tscovering membership, name→id coercion, and option-set validation)Checklist